Skip to content

feat: spark emergency exit - #1217

Merged
ovitrif merged 162 commits into
feat/breez-spark-lightningfrom
feat/spark-emergency-exit
Nov 12, 2026
Merged

ovitrif merged 162 commits into
feat/breez-spark-lightningfrom
feat/spark-emergency-exit

Conversation

@ovitrif

@ovitrif ovitrif commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1242
Closes #1296
Twin: synonymdev/bitkit-android#1782
Stacked on #1128

This PR adds Emergency exit, Spark's unilateral exit: if the Spark operators stop working, Spending moves to Savings on-chain without them. It builds on the Spark wallet in #1128, which already backs up Spark's exit data to VSS as SPARK_EXIT.

Description

What this PR does

  • Lightning Connections › Spark shows Emergency exit while Spark holds funds. It opens an explainer, a quote (amount, fees, what arrives, what stays on Spark), fee funding, a confirm swipe, progress and the result.
  • Fee funding is paid from Savings, or sent from another wallet to a shown address.
  • Progress shows the steps and the timelock countdown, asks for more fees when needed, and offers Pay a higher fee when the network outbids the exit.
  • If the Savings payment is refused, Progress says "Your savings payment didn't go through. Pay again, or fund from another wallet." and offers Pay from savings again.
  • The exit runs in the background with Bitkit closed and notifies only when it needs you. It can take up to 4 weeks.
  • When it ends, leftover fee funding goes back to Savings, and the result shows what reached Savings and what stayed on Spark.
  • A restored wallet keeps watching earlier exits' funding. The next funding index and the earlier exit addresses are in the VSS backup. After a restore from a backup without them, Bitkit scans the funding addresses (gap of 20, at most 1,000) and watches any that still hold coins.
  • The exit's strings are in English; translations follow in chore: translate spark strings #1270.

Decisions for review

  • Trust. The exit needs no operator: the app signs and broadcasts every step itself. On mainnet it reads the chain only from https://bitkit.to/electrs and broadcasts there first, with mempool.space and blockstream.info as fallbacks.
  • Funding key. The CPFP fees come from a key on its own account (m/84'/{coin}'/7797'/0/<index>, a fresh index per exit), so ldk-node never spends the exit's funding.
  • Fee funding. Savings or another wallet pays one amount, which the SDK splits into one output per step, so a lost fee race strands one step instead of the chain after it. A top-up asks only for what is still missing, at least the 294-sat dust limit.
  • Refused Savings payment. Pay from savings re-signs the same payment over the inputs it already spends, so at most one version can confirm. Cancel waits for it and keeps the exit.
  • Spark during an exit. No Lightning sends or receives on Spark, and leaf optimization stays off so no swap hands away an exiting leaf. The balance shows as in transfer to Savings on Home and Savings. Cancel works only while nothing was paid; funding already at the address goes back to Savings.
  • Saved before sent. Every build is saved before broadcast and every broadcast is saved as it is answered, so a killed run never sends a step twice.
  • Stalls. A broken fee chain rebuilds the exit; after 3 rebuilds with nothing confirming, Progress says it is not moving. A step that is only underpriced is not rebuilt; Progress offers Pay a higher fee instead.
  • What stays on Spark. Leaves too small to pay their own exit, and fee-ladder leaves that can't pay their refund, stay on Spark, and the quote says how much. A leaf an older owner already claimed on-chain is dropped and named on Progress.
  • Leftover sweep. It pays the network's cheapest rate and waits up to about 4 weeks for fees to drop before keeping the change at the funding address, whose derivation path the result shows.
  • Glow. Stall handling, waits for leaves that may still move, and top-up fees follow Breez's reference wallet, breez/glow-web. The lost fee race, lost leaves and background runs are our own handling, since Glow has none.

Background run on iOS

  • A BGAppRefreshTask and a BGProcessingTask run the exit with Bitkit closed, when iOS grants them. Each run sends the signed steps, starts Spark alone (never ldk-node), and stops cleanly when the OS expires it.

Notifications on iOS

  • Each run schedules reminders ahead, 12 blocks after each step matures, inside the operators' ~50-block window. A run that publishes the step removes its reminder.
  • Needing more fees, an exit that is not moving, and a failed exit notify at once.

Out of Scope

Risks for review

  • Mainnet. No exit has run on mainnet. Fee-ladder leaves and package relay at the three hosts are untested with a real exit. About 50 blocks (8 hours) after a step matures, the operators may publish their own version, paid from the leaf. iOS grants no background runs while Background App Refresh is off or Low Power Mode is on, and reminders need notification permission; Progress says so.
  • Cost. At 2 sat/vB, leaves under about 4,096 sats cost more to exit than they hold, so a typical balance recovers about 75% after fees, in about 13 to 14 days. With healthy operators, Transfer to Savings stays the cheaper way out, and the explainer says so.

Design

N/A — no design available.

Preview

Emergency exit at 6818189 on an iPhone 17 Pro 2x simulator (iOS 27.0) in Bitkit's dark theme. Start and Fund from another wallet run live against Spark's hosted regtest with notifications denied; another wallet pays the fee funding in two parts, and the waits for the payments are cut. Pay from savings is the earlier recording from dfc1716 and predates this PR's latest UI fixes (its step icons and inline transaction ID are the old ones): Savings on the simulator runs on a separate regtest chain, so a savings payment can't be recorded again here. Running exit, Redo and sweep, and Exit complete are staged from saved exit records on a debug build, each state shown in turn; Done with the leftover swept is shown through a local hook that is not in this PR.

Start: intro, quote, funding choices, confirm Pay from savings Fund from another wallet
01-start.mp4
p1217-2-savings2x.mp4
03-external.mp4
Running exit: timelock and sweep Redo and sweep Exit complete
04-running.mp4
05-redo.mp4
06-complete.mp4

Screens

Every exit screen, at 6818189 on an iPhone 17 Pro 2x simulator (iOS 27.0) in Bitkit's dark theme. Start screens and the notification prompt are from the live exit above; Savings is empty there, so Funding choices shows Pay from savings as unavailable and Confirm follows Fund from another wallet. Progress and Result screens are staged from saved exit records on a debug build, with Needs funding, partial and Payment seen reading real payments on Spark's regtest chain. Done, leftover swept and the Pay a higher fee rates are shown through a local hook that is not in this PR; Pay a higher fee reads 0 sat/vB because the staged record saves no fee rate, and its speeds are unavailable because Savings is empty.

Start
Spark detail during an exit Intro Quote with the can't-exit note Funding choices
Funding address Partial payment hint Notification permission Confirm
Progress
Needs funding Needs funding, partial Paid from savings at 27b2ce1 Payment seen at 27b2ce1
Running, network fees rose Pay a higher fee
Result
Leftover sending Done, leftover swept Stopped Stopped, balance stays on Spark
Lost to an older owner

QA Notes

Setup

  • Dev build (regtest) needs no key. Spark is funded from Lightspark's regtest faucet, sent to the deposit address on the Spark screen (Settings › Advanced › Lightning Connections › Spark). On dev, an exit's Pay from savings goes to Bitkit's staging chain and never confirms on Spark's; Fund from another wallet with the faucet does.
  • A full exit needs a chain you can mine: Lightspark's hosted regtest takes the real CSV of about 1,900 blocks, and its faucet needs credentials only Breez's CI holds. The exit results below ran on a local Spark regtest stack built from spark-sdk's public regtest/local setup (the last public operator), with bitcoind, 3 operators, the SSP, electrs and mempool, mined on demand. Two patches make SDK 0.27.2 work with that operator: the operator skips the RETURNED status filter it does not know, and the wallet turns fee ladders off, so every leaf there is an anchored leaf.
  • Besides the app, a small harness on SDK 0.27.2 made the same calls as the app's driver (prepareRecoverFunds with All, then Specific on a rebuild, recoverFunds with a single-key CPFP signer, checkRecoverFunds, package broadcast through mempool), each step a separate process resuming from storage.

Journeys

  • J1: new journeys/transfer/spark-emergency-exit.xml — Emergency exit up to the swipe: explainer, quote, funding choices and the funding address, with nothing started (ported from feat: spark emergency exit bitkit-android#1782). Needs Spark's regtest faucet for the balance.

Manual Tests

  • 1. Dev, Spark funded from the faucet, Settings › Advanced › Lightning Connections › Spark › Emergency exit → explainer, then the quote lists amount, network fees, You receive about, Fee funding needed and what stays on Spark — the faucet is outside the journey environment.
  • 2. Dev, continue with Fund from another wallet, send the fee funding from the faucet to the shown address, swipe → progress shows the funding step done after 1 confirmation, then publishing, the timelock countdown and the sweep; the exit survives an app kill and continues on reopen — faucet and hours of regtest blocks.
  • 4. Phone under Xcode, dev, an exit running: background Bitkit, pause in the debugger and run e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"to.bitkit.spark-exit"], continue → the log shows Spark exit background run: connected=true with the time per step; repeat with to.bitkit.spark-exit.processing, then once more followed by _simulateExpirationForTaskWithIdentifier: → the run stops, and the next run sends nothing already accepted. Turn Background App Refresh off for Bitkit → Progress shows the note; Low Power Mode on → its note — the simulator has no BGTaskScheduler.
  • 5. Dev, Spark funded, Emergency exit with Pay from savings → Progress shows "Paid from savings, waiting for confirmation" with its transaction ID and no QR or ask; Cancel exit backs out — Spark's regtest is not in the journey environment.
  • 6. Dev, Emergency exit with Fund from another wallet, pay the address from the faucet → Funding shows Payment seen, Progress shows "Payment seen, waiting for confirmation" with its transaction ID until it confirms, then publishing — faucet.
  • 7. Local Spark regtest, an exit that ends after a Redo needing no funding → Emergency exit and the Spark row open the result with the leftover sweep sending, then sent with its transaction ID; a new intro shows only after it confirms — needs a chain you can mine.
  • 8. Local Spark regtest, a second exit on the same wallet → its funding address differs from the first exit's — same.
  • 9. Local Spark regtest, kill Bitkit while refunds wait for their timelock, mine to maturity and relaunch → nothing is sent before it matures, and non-BIP68-final never counts toward the 3-refusal alert — same.

Automated Checks

  • SparkExitReviewTests covers cancel (off once paid or seen; funding at the address is swept back, an unreadable address keeps the exit), no height from a failed tip read, the live generation driven while a higher fee's top-up confirms, a dropped or stale savings payment brought back to an ask, a diverged rebuild's top-up sending nothing, the snapshot imported before fee-bump pricing, the reminder for an unfunded exit, fired reminders remembered, the sweep's bounded states, salvage keeping chained transactions, the connect timeout returning on time, cancel revalidating a built record, a confirmed partial sweep following what it left out, only-failed cooperative leaves waiting for the operators, fee-bump pricing past a refused leaf, per-exit reminder ids and the background chain's economy rate; SparkExitBackgroundRunTests covers a failed pass handing back the saved record.
  • SparkExitReviewTests also covers a refused savings payment shown until a payment is kept, and paying again retrying only the refused attempt.
  • Per-step funding, Pay a higher fee, retried leaves and the fee change sweep: unit tests in SparkExitFeeBumpTests and SparkExitSweepTests, plus dark-mode screens rendered with stubbed state by a local hook not in this PR. Not run on a live chain yet.
  • added SparkExitTests.swift — the background entry point from a cold start sends stored steps and then checks with the SDK only, signs a build once funding confirmed, stays keyless when Spark does not connect or time is short, and after an expiry mid-broadcast leaves a whole record from which the next run sends nothing twice; reminders are planned at each unpublished timelock plus the grace, dropped once a step is published, sent at once for fee funding and repeated refusals, and after a stall while steps wait for parents; the record round-trips with every status, error and input variant; the recovery is written in spark-sdk's serde shape and a CLI-written one reads back; an unknown version is refused; the driver waits for a confirmed funding UTXO, saves before broadcasting, broadcasts ready steps once until due, finishes on Done with the leftover read, rebuilds a new generation on Redo after saving it, asks only for what the inputs lack on InsufficientCpfpFunds, adopts several small payments together and ignores CPFP change at the funding address, resumes after a restart without repeating broadcasts, retries cooperative leaves the operators could not sign, and broadcasts without the SDK only steps whose parents confirmed and timelocks matured; a leaf whose refund two rebuilds reproduce after its input was spent is lost, alone or with every leaf (the exit stops); a rebuild leaves behind a leaf that cannot pay its refund and retries, falls back to RecoverableOnly when the error names no leaf, and stops when none is left; the quote math, funding states, funding key path, the package and single broadcasts, path and host fallback, and the node answer classifier
  • added SparkExitSweepTests.swift — the leftover sweep spends every UTXO at the funding address to Savings less its fee, each input's signature verifies against its BIP143 digest with libsecp256k1, a leftover under dust after the fee is skipped, output scripts for every address type, an ended exit keeps its result reachable until the sweep confirms, and a background run sweeps without starting Spark
  • added SparkExitRegtestTests.swift — opt-in (TEST_RUNNER_SPARK_EXIT_REGTEST=1): a fresh wallet on Spark's hosted regtest connects and quotes an empty exit, the chain host serves the tip and funding UTXOs, a fake parent and child package reaches the live node and is read as retry later, and the background entry point runs from a cold start over an exit of the live run's shape
  • SparkExitTests.swift also covers a fee chain that lost a race, with the operators down and the check answering Valid (rebuilt from the same funding, the dead chain never resent), a child still in the mempool, the safety net's rebuild, which never counts as a refusal, the chain host's not-found lookup, and the needs-funding amount never under the 294-sat dust limit, and a replay of a single-leaf exit an older state claimed, which across its rebuilds delivers only the lost notification
  • SparkExitTests.swift also covers mainnet reading only from bitkit.to/electrs and broadcasting there first (packages on /txs/package) with mempool.space and blockstream.info as fallbacks, a transaction counted gone only on a 404 from the one read host (never on a 5xx or timeout), lost leaves from a stored record joining the lost-leaf list on load, ended exits included, and lost leaves kept out of the stays-on-Spark and can't-be-exited amounts whether the Spark balance still counts them or not
  • Stalls, leaf waits, exit data, sweep replacement, salvage, countdown and top-ups, in SparkExitTests.swift, SparkExitSweepTests.swift and SparkExitBackupTests.swift:
    • refused steps rebuild 3 times while no step confirms, then the exit is not moving; a step confirming resets the count; a stalled-chain rebuild strikes no leaf;
    • an accepted step unconfirmed for over 6 blocks is underpriced and not rebuilt, and gets one reminder; a nonzero-fee refusal under the mempool's minimum (both node texts) shows the same at once without a rebuild, while a zero fee and a lost replacement race still rebuild; a step confirmed since is not stuck;
    • an unverified unilateral step is not sent and the exit rebuilds over it once a block; a ready step holds that rebuild, and a cooperative unverified step is still sent;
    • funds not found waits, retries after 6 blocks at doubling gaps up to 144, and lets go after 1,008; a stopped leaf waits 6 blocks, then is let go; a stopped leaf still waiting is never struck;
    • the exit data is exported once before the first build and imported before every rebuild; the SPARK_EXIT backup is not uploaded while an exit runs;
    • a sweep refused for its fee is rebuilt at the network's rate, one unconfirmed for 144 blocks is replaced, a replaced sweep confirming ends it, and a replacement that would be dust keeps the accepted sweep;
    • a record of an unknown version keeps its exit, with its funding and transfer, to rebuild from the chain; an ended or unusable one is not revived; one that cannot be read yet is left alone;
    • the countdown holds when a parent confirms; Done reads what reached savings, a cooperative recovery from before a Redo included, and the sweep's txid; each extra payment adds its 68 vB input fee, so the run's 8,541 + 4 × 136 matches the SDK's 9,085
  • updated SparkConfigTests.swift — leaf optimization is on, and off while an exit runs
  • ran the unit suite (CI's selection) on this head and on feat: add spark via breez sdk #1128's head; SwiftFormat and the translation check pass

Test results

Full in-app exit on Spark's hosted regtest, at 2a203b4 on an iPhone 17 Pro 2x simulator (iOS 27.0), dark mode, a fresh dev wallet:

  • 🟢 Start and funding: a 25,000 sat deposit (24,901 after the claim fee) quoted 20,480 to exit, 9,077 in fees, about 11,403 received, 15,253 of fee funding in 24 parts and 4,421 staying on Spark as balance it can't exit. Fund from another wallet: the faucet paid 15,253 to the funding address, Funding showed Payment seen, and after the swipe Progress showed the funding step done and published the first 7 of 29 transactions, each accepted. Notifications were denied, and Progress shows that note.
  • 🟠 Done: running, ~33 h of regtest timelocks. Hosted regtest mines a block about every 31 s and can't be mined on demand, and Progress reads Ready in 23d at mainnet block times (about 3,300 blocks). The app stays open on the simulator, since the simulator has no BGTaskScheduler. This line is updated at Done with the sweep and what reached the destination.
exit1-start2x.mp4

QA of SDK 0.28.0 (comment), at 328b29a on an iOS simulator, local Spark regtest plus one exit on Spark's hosted regtest:

  • 🟢 Pay from savings with per-node funding, the partial top-up hints with each extra input's fee, Pay a higher fee (paid and already covered), an evicted fee-change sweep retried at the new floor, a 0.27.2 record resumed on 0.28, a lost race on a per-node funded node, a stale state, Redo with the leftover sweep, the rebuilt-from-chain note, and the hosted fan-out and node packages.
  • 🟠 The lone-transaction package retry was not reached: the hosted host now answers /tx with real codes. Unit tests cover it.
  • ⏭️ Not covered: the failed cooperative leaf retry and release, the OperatorsUnavailable wait, and the fee-change wait from high network fees. The changes from the independent review are unit-tested only.

QA (earlier):
Tested on an iPhone 17 Pro simulator (iOS 27.0) against Spark's hosted regtest, and on an iPhone 17 Pro simulator (iOS 26.5) against a local Spark regtest stack (QA Notes, Setup).

Device tests skipped ⚠️:

  • Background runs: the simulator has no BGTaskScheduler (4).
  • Mainnet, a fee-ladder leaf exit and the leftover sweep on a funded run: see Mainnet tests.
🟢 Exit paid from savings, Exit funded from another wallet
Exit paid from savings

1 and 5 at aa01843: the quote read 90,112 to exit, 7,969 in fees, about 82,143 received, 8,257 of fee funding and 9,789 staying on Spark, named as balance it can't exit; Pay from savings sent 8,679 from Savings, and Progress showed it as paid with its transaction ID and no QR, with the notifications note after permission was denied; recording under Preview.


Exit funded from another wallet

6, and 2 up to publishing, at aa01843: the faucet paid 8,257 to the funding address; Funding showed Payment seen, Progress waited for it with its transaction ID, and once it confirmed the exit built 28 transactions and published the first two; recording under Preview.


🟢 Full exit to Savings, Resume after a kill
Full exit to Savings

On the local stack at 030de21, with the operators running: a 100,000 sat Spark balance quoted 90,112 to exit, 4,855 in fees and 6,031 of fee funding; Pay from savings sent 6,453 (the funding plus its fee). The app published each refund once the 1,900-block timelock matured, before the operators' 1,950-block version, and sweep 02c28284… confirmed at height 9,246, paying 91,288 sats to Savings (90,112 − 4,855 + 6,031). The result read "Exit complete. Your funds are in savings. 18 small amounts stayed on Spark."; recordings under Preview. That build still asked to send the funding after paying it, which 169ff91 fixes.


Resume after a kill

Bitkit terminated at 6 of 22 transactions with one block mined while closed reconnected on relaunch and broadcast the next packages.


🟢 Rerun on the fixes, Failed exit
Rerun on the fixes

On the local stack at 674f0da, one simulator wallet, two exits, leaf optimization off in the test build:

Case Result
Quote names the balance it can't exit 🟢 "₿ 1,696 of your Spark balance can't be exited right now"
Top-up as two payments 🟢 2,000 then 2,348; Payment seen once the sum reached 4,348
Progress shows the balance outside the exit 🟢
Redo after the operators broadcast first 🟢 generation 2, sweep of 96,237 to Savings
Leftover sweep 🟢 1,394 and 978 back to Savings
Pay from savings shows paid, waiting for confirmation 🟢 with its transaction ID, no address or QR
New funding address per exit 🟢
An older owner claims a leaf 🟢 dropped after two identical rebuilds (about 10 minutes); the rest swept 65,437, and Done names the lost 8,192

Failed exit

At 41d8569: a single-leaf exit (32,768, received straight from a previous owner) with the operators down, where the previous owner's older refund won while the app was closed. About 16 minutes after relaunch the app dropped the leaf, swept the 626 leftover to Savings and showed two notifications. Emergency exit kept the result until it was opened once ("Exit stopped", "₿ 32,768 was claimed on-chain by an older owner of these funds and can't be recovered", and the leftover line); the next opening showed the fresh intro.


🟢 Operators stopped, Operators broadcast first, Funding short, Stale state
Headless runs through the harness, all at 2 sat/vB, each wallet funded with a 100,000 sat deposit (99,865 after the claim fee), which the SSP splits into 17 power-of-two leaves.

Operators stopped

With all 3 operators stopped, SDK 0.27.2 connected in 29 ms, quoted from local storage in 0.1 s and built 25 transactions (1 fan-out, 19 node packages, 4 refunds with a 1,900-block CSV, 1 sweep); every package was accepted. 4 leaves (90,112 sats) went out, 13 leaves (9,753 sats) were skipped as FeeExceedsValue, and 91,680 sats reached the destination (90,112 plus 1,568 unused funding through the sweep) over 1,961 blocks, about 13.6 days on mainnet.


Operators broadcast first

With the app asleep 60 blocks past maturity, the operators' watchtower broadcast its own refunds (1,950-block CSV, fee from the leaf); the next check returned Redo (OnChainStateDiverged), the Specific rebuild of the same 4 leaves needed no funding, and the exit reached Done with 85,749 sats. The 5,035 sats of first-generation change it left behind are what the leftover sweep now sends to Savings.


Funding short

With 9,035 sats over 5 UTXOs against a 8,541 quote, recoverFunds failed with InsufficientCpfpFunds asking for 9,085 in total; a 1,000 sat top-up was adopted with the earlier UTXOs, and the exit reached Done. The app now asks for the 50 missing instead of 9,085.


Stale state

A previous owner broadcast its older refund for a 16,384 sat leaf once it matured, 100 blocks after the current owner's, while the current owner slept, and swept it. The current owner's check returned Redo, and each rebuild reproduced refund be4def69…, refused as bad-txns-inputs-missingorspent. The app now drops such a leaf after two identical rebuilds (Rerun on the fixes). With the operators down, the 100 blocks after each maturity are the hard deadline, about 16 hours on mainnet.


Evidence

  • Stalls, leaf waits and the other Glow-aligned behaviour at df489ccd: the exit tests and the unit suite (CI's selection) pass on an iPhone 17 Pro simulator (iOS 27.0); SwiftFormat and the translation check pass. Not run on a regtest stack yet.
  • One notification for a lost exit, at 5d18a238: the exit tests and the unit tests (CI's selection) pass on an iPhone 16 simulator (iOS 27.0); SwiftFormat passes.
  • Fee chain that lost a race, and the dust floor on the needs-funding amount, at 46ae7556: the exit tests and the unit suite (CI's selection) pass on an iPhone 16 simulator (iOS 27.0); SwiftFormat passes.
  • Emergency exit fixes from the local regtest runs (result after Done, no double pay, a fresh funding address per exit, keyless timelocks, lost and unexitable leaves, the quote's unexitable amount) at 174d9ed7: the exit tests and the unit suite (CI's selection) pass on an iPhone 17 Pro simulator (iOS 27.0); SwiftFormat and the translation check pass.
  • Emergency exit funding, leftover sweep and late value at 022d3ed2: the exit tests and the unit suite pass on an iPhone 17 simulator (iOS 27.0).
  • Emergency exit in the background at 335182af: the unit suite and the exit tests pass on an iPhone 17 simulator (iOS 27.0). Against Spark's hosted regtest, the background entry point from a cold start, over a stored exit of 30 steps: keyless pass 1.03 s (7.00 s before status reads ran concurrently), SDK connect 0.03 s (before the SDK's own sync), checkRecoverFunds on one real step 0.55 s (0.17 s warm), one package broadcast to the live node 0.17 s, prepareRecoverFunds 0.55 s, whole run 1.26 s against a 25 s refresh budget; the process footprint grew by about 2 MB (25 to 27 MB, in a test host with no UI).
  • Emergency exit at f6d639cb: SparkExitRegtestTests passes against Spark's hosted regtest (empty-wallet quote, chain reads, a live package answer).

Open

  • UI polish waiting on design. The new exit screens (Pay a higher fee, the funding split line, the fee-change and cancelled results) are built from existing components; they wait for a design pass.
  • Hosted-regtest run. A full exit by a harness on SDK 0.27.2, making the same calls as the app's driver, reached Done on Spark's hosted regtest: all 19 steps confirmed and the sweep confirmed. The app's own full exit there is running (Test results).
  • Mainnet exit and fee-ladder leaves. Under Mainnet tests.
  • BGTaskScheduler on a real device. The simulator has no BGTaskScheduler, so the OS granting and expiring the exit's tasks is not exercised (4).
  • On dev, the Home countdown compares a Spark-chain timelock height with the staging chain's height, so it is wrong there; the progress screen uses Spark's chain.
  • The result after Done with its sweep, the fresh funding address, the keyless timelocks and lost or left-behind leaves are covered by unit tests only (7 to 9). A lost or left-behind leaf's value stays in the exit's in-transfer amount until the exit ends.
  • An optimization that starts while Spark connects is not seen, since the event listener is added after connect returns, so a quote right after launch does not wait for it.

Mainnet tests (run by the author)

These run on real devices with mainnet funds, by the author. Reviewers and review bots don't need to run them, and they don't block review.

Round 1

  • Read-only check on a fresh wallet with nothing funded: Spark connected and synced on https://bitkit.to/electrs, Emergency exit opened up to the quote (Nothing can be exited right now), and the SDK's fee estimate and the exit's chain reads went to https://bitkit.to/electrs only. Nothing was broadcast. On 7220fd0 on a simulator, with debug-only logging (result not posted).

Round 2 (pending)

On the final heads, per the small mainnet exit: a fresh wallet, a low-fee week (about 2 sat/vB) and about 20,000 sats in Savings for the fees.

  • Read-only check again on the final head.
  • Deposit about 50,000 sats into Spark and wait for it to settle; the quote lists the leaves it moves, what stays on Spark and the total fee.
  • Start, with the fees paid from Savings: Home shows the amount in transfer with a countdown, and Spark stops sending and receiving.
  • Each step confirms, with packages accepted at https://bitkit.to/electrs, and none is published by the operators first.
  • Reminders: a notification when a step needs the app opened, in time for its window of about 50 blocks.
  • Restart the app mid-exit: it resumes and nothing is broadcast twice.
  • Fee-ladder leaves: their refunds go out and confirm (unit tests only so far).
  • The leaves are swept to Savings, Savings gains the exited amount minus the fees, and leftover fee funding returns to Savings.
  • The exit screen shows Done, with one completion notification.

Models used

  • Planning/scoping: claude-opus-5-5 (reasoning: medium), claude-sonnet-5-5 (reasoning: high)
  • Implementation: claude-opus-5-5 (reasoning: medium)
  • Review: claude-sonnet-5-5 (reasoning: high)

Taken from the run logs of every session and subagent that planned, wrote or reviewed this PR, which record the model and reasoning effort per turn. The claude-sonnet-5-5 planning runs were research and audit subagents.

Moves the Spark balance on-chain without the Spark operators: quote, fee funding from Savings or another wallet, swipe to start, then a driver that builds, broadcasts and checks the SDK's recovery until it lands in Savings, after restarts and from background tasks too. Lost leaves are given up, fee funding left over is swept to Savings, and Spark stops sending, receiving and optimizing leaves while the exit runs.
@ovitrif

ovitrif commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

QA on 5d18a238: dev build on an iOS simulator, against a local Spark regtest stack with the operators stopped, so every exit is unilateral.

  • 🟢 Lost race on a shared tree node, against an Android wallet exiting a sibling leaf whose package went out first. The exit carried on by itself: it rebuilt about 9 min after the loss and swept to savings.
  • 🟢 Stale state (an older owner publishes its refund first): the exit ends Failed with exactly one notification after the relaunch.
  • 🟢 The stopped screen says "Nothing could be recovered. ₿ 3 392 stays on Spark." It stays until it's seen, and reopening Emergency exit shows the intro.
  • 🔴 A leaf lost to an older state comes back in the next exit, on Android too (feat: spark emergency exit bitkit-android#1782):
    • The operators still list it as ON_CHAIN under this wallet, so the next quote counted it: ₿ 131 072 over 2 leaves instead of ₿ 65 536.
    • Its refund fails with "inputs missing" on every tick, and the driver logs "diverged on-chain" and rebuilds after each confirmation, about 10 min per rebuild.
    • It then drops the leaf again as "claimed by an older state" and sends a second "can't be recovered" notification for a loss the user was already told about.
    • The exit itself finished and swept 66,457 sats (488a4e32). Leaves already reported as lost should be left out of later quotes and exits.

Stale-state result, kept until seen:

@ovitrif

ovitrif commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Fix for the leaf that came back in the next exit (QA comment), in 57118a6.

After an older state claims a leaf, the operators keep listing it under the wallet. The lost leaf was only on the exit record, so the next exit quoted it again. It then rebuilt for each confirmation, lost the leaf a second time and notified twice.

  • Leaves reported lost now go into a per-wallet list that outlives the exit record. It sits beside the record, per wallet and network, and is cleared only when the wallet is wiped. It isn't backed up, since a restored wallet finds the loss again in one rebuild cycle.
  • The quote leaves those leaves out. The rest is prepared again with Specific, so the amount, fees and funding are right.
  • When every leaf the operators list is a lost one, the quote shows nothing to exit.
  • When a rebuild falls back to RecoverableOnly, which lists every recoverable leaf of the wallet, it now prepares only the exit's own leaves. An earlier lost leaf therefore can't come back during an exit either.
  • No second "can't be recovered" notification follows, since a later exit never holds the lost leaf.
  • The leaf ids in the logs aren't crossed. The app logs the refund's nodeId, which spark-sdk sets to the leaf's own id. The two leaves came from the same older owner, and their ids differ by one hex digit.

Unit tests cover the list surviving a new exit, the quote and selection filters, an all-lost quote and the single notification.

@ovitrif

ovitrif commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

QA on 57118a69: dev build on an iOS simulator, against a local Spark regtest stack with the operators stopped. I installed it over the previous build, keeping the wallet.

  • 🟢 Case 5 again (an older owner claims the leaf first): the exit ends Failed with exactly one notification, and spark-exit-lost-leaves.json records the leaf.
  • 🟢 With only lost leaves left, the quote shows ₿ 0 and "Nothing can be exited right now.", and Continue is disabled (screenshot below).
  • 🟢 A new exit after that quotes only the live leaf (₿ 65 536 over 1 leaf). It logs no "diverged" or "inputs missing", sends no second notification, and swept 65,830 sats to Done (7c7e79a4).
  • 🟠 A leaf lost before this update isn't in the file. The first quote after the update counted it again, and that exit rebuilt three times ("diverged", about 10 min each) before dropping it. Android (feat: spark emergency exit bitkit-android#1782) left its pre-fix leaf out from the first quote. This only affects leaves lost before the update.
  • 🟠 Right after case 5:
    • The stopped subtitle read only "Nothing could be recovered.", without "₿ 3 392 stays on Spark.".
    • The quote's "can't be exited" line and Home's Spending both showed ₿ 68 928, while "Stays on Spark" showed ₿ 3 392.
    • All three were back to ₿ 3 392 about 10 min later. The Spark balance seems to count a just-lost leaf until the next sync.

Not covered: the later commits that move the mainnet chain service to Bitkit's electrs. This regtest build points the chain service at the local stack.

- rebuilds over refused, unpaid or missing-input steps and a dead CPFP child at most 3 times while no step confirms, then says the exit is not moving
- shows a step accepted but unconfirmed for over 6 blocks as underpriced, without rebuilding
- never sends a unilateral Unverified step from the SDK path; rebuilds over it once a block
- waits for leaves skipped as Unverified, FundsNotFound or recently stopped, retrying at 6 to 144 blocks and letting go after 1,008
- only a Redo rebuild adds lost-leaf strikes
- keeps the exit data exported before the first build and imports it before every rebuild
- rebuilds a leftover sweep refused for its fee, and replaces one unconfirmed for 144 blocks
- keeps an exit whose record can't be read, rebuilding it from the chain with its funding
- counts down along the longest path of steps left, shows what reached savings, and adds each extra payment's input fee
- ends a background run whose record can't be read before the first unlock, and reschedules
The SPARK_EXIT backup is not refreshed while an exit runs: the operators may stop reporting a leaf once it is being exited, so a fresh export could lack the leaf a restored wallet needs.
Tapping a Spark exit notification opens the exit's progress once the wallet is unlocked.
- a refusal for a nonzero fee under the mempool's minimum (min relay fee not met, N < M with N > 0, or mempool min fee not met) shows fee_low at once, without a rebuild, and its reminder keeps its first date
- a zero fee and a lost replacement race stay on the broken-chain path
- an unpaid child is never a refusal, a step confirmed since is not stuck, and Done counts what landed before a Redo
- Exits prepare with per-node CPFP funding on SDK 0.28 and resume with the
  strategy they started with; one payment of the quote's funding, which
  the SDK splits per step. Fan-out outputs no longer count as new funding.
- Progress offers "Pay a higher fee" when the exit is underpriced or the
  network's next-block rate passes its own: each higher rate with its cost,
  paid from savings, then the unconfirmed steps are rebuilt and replaced.
- Cooperative leaves the operators moved on-chain but could not co-sign
  are retried for about a week instead of ending the exit, and the result
  says plainly what is left and that it is not on Spark.
- The pre-start partial-payment hint includes the next input's fee.
- Done starts with the amount that reached savings and keeps the sweep's
  transaction ID after a failed-leaf retry replaced its generation.
- Released leaves: "The operators can still release it to you."
- Pay a higher fee says the amount next to each speed is what savings
  pays, and says savings can't cover it when no rate is affordable.
@ovitrif

ovitrif commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

QA of the Glow hardening on df489ccd: dev build on an iOS simulator, against a local Spark regtest stack with the operators stopped except where a case needed them. The later 0.28.0 commits (per-step funding, fee bump) aren't covered.

  • 🟢 Lost race on a shared node: "fee chain lost a race", rebuilt 9 min later, then Done.
  • 🟢 Stale state: two "diverged on-chain" rebuilds, then the leaf is dropped, with one notification and a Failed result.
  • 🟢 Redo after the operators publish first:
    • "Imported the Spark exit's own exit data … '17' already held" before each rebuild, then the sweep (64,482 sats) and the leftover sweep.
    • The only SPARK_EXIT backup came after the exit ended.
    • Done reads "₿ 65 830 reached your savings." with the sweep's transaction ID.
  • 🔴 Partial top-up:
    • After a 1,283 payment toward ₿ 2 283, the hint said "send ₿ 1 000 more".
    • Paying exactly that then asked "₿ 2 283 of ₿ 2 577 received, send ₿ 294 more."
    • So the extra input's fee shows only after the start. Android states the same total as ₿ 2 419 (feat: spark emergency exit bitkit-android#1782).
  • 🟢 Low fee: a step accepted but held out of 8 blocks showed "The network is busier than this exit's fee rate of 1 sat/vB…" (SparkExitFeeLow) with no rebuild.
  • ⏭️ Low fee, a single-transaction refusal with N > 0: the sweep, iOS's only such step, went out before the relay floor was raised. A refused CPFP package reports "0 < 418" and takes the rebuild path, as designed.
  • 🟢 Not moving: three rebuilds were refused as replacements, Progress said "The exit is not moving: rejected: insufficient fee, rejecting replacement …", and no fourth rebuild followed. The reminder fired once the app was back in the foreground. While suspended on the simulator, no background run came.
  • 🟢 Notification tap: tapping the banner opened Emergency Exit. The tap was tested with a simulator push carrying the app's bitkit_action: spark_exit payload, because the foreground-presented notification didn't stay in Notification Center.
  • 🟠 Record of version 2 on disk:
    • The app logged "Rebuilding the Spark exit from the chain, its record could not be read: UnsupportedVersion(version: 2)" and kept the original as spark-exit.unreadable-….json. The exit stayed visible.
    • Progress didn't show the rebuilt-from-chain note about 3 min later.
  • ⏭️ Not covered:
    • An Unverified step, and a skip of Unverified/FundsNotFound/stopped leaves: the SDK and the app share one chain host here.
    • A fee-refused leftover sweep: the exit ended with no leftover.
Low fee Tap opens the exit

@ovitrif

ovitrif commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

QA on 3d871fbc: dev build on an iOS simulator, Spark on its hosted regtest, funded with 20,000 sats as a Spark transfer.

  • 🟢 transfer/savings-select-funds.xml, Spark alone: confirm shows the whole ₿ 20 000, "Spark fee ₿ 2 190" and no Advanced button.
  • ⏭️ transfer/savings-select-funds.xml, the channel rows: no channel was staged.
  • 🟢 Manual test 1: Emergency exit opens the explainer, then the quote shows Amount to exit ₿ 8 192, Network fees ₿ 2 408, You receive about ₿ 5 784, Fee funding needed ₿ 3 851 and Stays on Spark ₿ 11 808.
Transfer to Savings, Spark alone Exit quote
select-i.mp4

The next exit funding index of each wallet now rides the metadata backup's cache as sparkExitNextFundingIndexes, keyed <network>/wallet<n> like bitkit-android, so a backup restores on either app. A restore keeps the higher of the backed-up and the device's index, and a backup without the field restores none.
A wallet restored from the phrase, or from a backup older than the counter, started its next exit at funding index 0. With no counter kept and no record, the quote now reads the funding addresses from index 0 until 20 unused ones follow the last used (at most 1000), takes the index past it, and keeps it. A scan the chain fails fails the quote and keeps nothing.
The earlier exit addresses each wallet watches, kept change and cancelled exits' funding, lived only on the device. The metadata backup now carries them as sparkExitEarlierAddresses, keyed <network>/wallet<n> with the kind and time in milliseconds as bitkit-android writes them. A restore lists them next to the device's own entries and skips a kind it does not know, and a list it can't read never fails the rest of the envelope.
Funding an exit received at an address above index 0 went unwatched once the wallet was restored, as its record is not backed up. On each start after a restore (with no funding counter kept, or once after a restore brought one back), the exit reads its funding addresses, and lists each one that still holds coins and nothing watches as kept change, so it is watched for good and shown with what it holds. A read that fails keeps the restore pending, so the next start reads again.
@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 31be1fe: the latest review round, base synonymdev/bitkit-ios#1128 at 18946a0 merged in (no conflicts), and the fix for synonymdev/bitkit-ios#1296. Each fix has a test that failed first. The full BitkitTests suite passes (3,728 tests, 0 failures, with CI's skips), and SwiftFormat lint is clean. No new strings.

@pwltr (review 5863556675; its findings were marked unconfirmed, so I checked each one against Spark SDK 0.28.0)

  • High, an unexitable leaf bypasses the guard: addressed in 437a979 and 5176fa0. It holds: prepare omits a pending watchtower-exited leaf as unverified (recover_funds.rs#L1098-L1123), and the SDK keeps nothing across generations. Answered in @coreyphillips's thread too.
  • Medium, the regtest chain host is hardcoded: addressed in 754725c. The SDK never reads SPARK_CHAIN_SERVICE_URL. The app reads it and passes it to the SDK, which otherwise uses its own default host (sdk_builder.rs#L924-L936). On regtest the exit now reads and broadcasts through that URL when it's set, and through the SDK's default otherwise.
  • Medium, Savings funding without a chain check: addressed in 15f76ba. Savings funding now needs SparkManager.shared.canFundFromChain, as the transfer screens do. The Android twin is in synonymdev/bitkit-android#1782.
  • Low, kept change at a reused address: addressed in 78cacc0. On iOS, a new exit's start never removed the cancelled entry, so this was reachable.
  • Low, the seen flag: addressed in d8a058c, with @coreyphillips's two threads.

Also in this round:

  • 437a979, fd9c887: every rebuild over a valid exit keeps that exit's signed steps, including both fee-bump paths.
  • 1af56fc: a funded unverified resume runs once a block.

Restore (synonymdev/bitkit-ios#1296, now closed by this PR):

  • 7622b77: the exit funding counter is in the VSS METADATA backup, and a restore keeps the higher value.
  • fdfd7cd: without a backed-up counter, the funding addresses are scanned at start (gap of 20, at most 1,000).
  • 9659b95: the earlier exit addresses are backed up too.
  • 31be1fe: after a restore, every funding address that still holds coins is watched again as kept change.
  • The Out of Scope bullet for this is removed.

Re-review is needed only because of this push: 63a2f49...31be1fe

@ovitrif
ovitrif dismissed pwltr’s stale review November 11, 2026 22:52

Addressed in 437a979..31be1fe; see the PR comment.

@coreyphillips coreyphillips left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked after 16 new commits (199729d, 23e71bd, 18946a0, and 13 more). Nothing blocking remains.

addressed

  • An address refresh can erase newly archived exits (Bitkit/Managers/SparkExitManager.swift:160-163). Fixed: SparkExitManager.refreshEarlierAddresses(store:fallback:now:utxos:) (SparkExitManager.swift:184-202) reloads the store after the balance reads (current at :193). It merges the balances into that reloaded list, so an address added during the reads stays. testAnAddressRefreshKeepsAnAddressAddedDuringItsReads (SparkExitReviewTests.swift:820) adds an address mid-read and checks that both are kept.
  • Unexitable-leaf error in an unverified rebuild bypasses the new guard (Bitkit/Services/SparkExit/SparkExitDriver.swift:397-404). Fixed: build now takes valid from record.validGeneration (SparkExitDriver.swift:358, :1481), so the isRebuilding rebuild on the next tick is guarded too. On an unexitable-leaf error, :409-415 now restores the valid generation (with the left-behind leaf) whenever it recovers a leaf the build left waiting, without setting isRebuilding. I traced both paths. The single-tick case is covered by the test at SparkExitTests.swift:1905 and the two-tick case by the one at :1907.
  • The foreground tick undoes the reset that shows a refused sweep's result again (Bitkit/Managers/SparkExitManager.swift:591-595). Fixed: SparkExitManager.swift:607 now also requires current.isResultSeen != true. A result that was already seen before the tick no longer overwrites the driver's reset. testTheForegroundTickKeepsTheDriversResetOfASeenResult runs the kept-change case through SparkExitManager.tick and checks both the in-memory and the stored record.
  • A seen result suppresses a later leftover-sweep refusal (Bitkit/Services/SparkExit/SparkExitDriver.swift:1112-1114). Fixed: Same change at SparkExitManager.swift:607. The restore applies only when isResultSeen was not already true at tick start, so the nil the driver sets on a final sweep refusal (SparkExitDriver.swift:1114) now survives the foreground tick.

still open

  • Three exit strings are missing from every non-English locale (Bitkit/Resources/Localization/en.lproj/Localizable.strings:454-455). Three new keys exist only in English: lightning__spark_exit__cancel_title, lightning__spark_exit__cancel_text, and lightning__spark_exit__note_notifications_button. Comparing all locale files confirmed they are absent from all 14 other locales. The cancellation dialog and notification settings button therefore fall back to English. Still open. en.lproj has 90 lightning__spark_exit__ keys and each of the 14 other locales has 84. Six keys exist only in English: cancel_title, cancel_text, note_notifications_button, earlier_addresses, earlier_addresses_text and earlier_path. No commit in this round touched a non-English locale.

new since the last review

worth doing, does not block

  • A fee bump kept over a waiting leaf is recorded as applied but never built (Bitkit/Services/SparkExit/SparkExitDriver.swift:1481-1488)
  • Pay a higher fee still pays from Savings on a chain Spark does not read (Bitkit/Views/Settings/Advanced/SparkExitViews.swift:912-915)

@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Reran Run Tests on 31be1fe. All 3,728 unit tests passed. The 2 failures are the UI template tests (testExample, testLaunchPerformance), which timed out acquiring a background assertion on the runner's simulator, so they failed before testing anything.

@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 9fab00f:

  • 793be4a: the base, now with synonymdev/bitkit-ios#1191 merged, merged in without conflicts.
  • 9fab00f: the join of Route and Exit, which git can't flag. A contact withdrawal is refused while an exit runs, and its send is counted in the exit's spends, so an exit starting meanwhile waits for it or refuses it. A test that fails without it is included.

Re-review is needed only because of this push: 31be1fe...9fab00f

A fee bump whose build would leave a leaf waiting kept the valid generation
with the bumped rate on the record but the old rate in its signed steps, and
nothing built at the new rate afterwards. The bump now stays pending until a
build at its rate is adopted: the check builds at that rate again once a
block, whatever the leaf waits for, and the kept generation is still sent
meanwhile.

A dead fee chain beside a waiting leaf keeps the valid generation too, so the
leaf's signed steps stay; its rebuilds stop once stalled and it rebuilds once
the leaf's step confirms. A test pins that.
The Pay a higher fee screen and its payment checked only the spendable
balance. On a chain Spark does not read, such as its hosted regtest, the
extra funding went out on Bitkit's chain and the exit never saw it. Both now
use the same chain check as the start's funding screen; a rate with nothing
to pay is still offered.

@coreyphillips coreyphillips left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked after 89 new commits (9f6fbd6, c32983d, 82f2bb1, and 86 more). Nothing blocking remains.

still open

  • Three exit strings are missing from every non-English locale (Bitkit/Resources/Localization/en.lproj/Localizable.strings:454-455). Three new keys exist only in English: lightning__spark_exit__cancel_title, lightning__spark_exit__cancel_text, and lightning__spark_exit__note_notifications_button. Comparing all locale files confirmed they are absent from all 14 other locales. The cancellation dialog and notification settings button therefore fall back to English. No locale file changed for this, and nobody replied to it. cancel_title, cancel_text and note_notifications_button are still in en.lproj only: grep finds 0 of them in each of the 14 other locales. Three more English-only keys are in the same state (earlier_addresses, earlier_addresses_text, earlier_path).
  • A fee bump kept over a waiting leaf is recorded as applied but never built (Bitkit/Services/SparkExit/SparkExitDriver.swift:1481-1488). Since 437a979, a fee-bump build that would leave a leaf waiting does not adopt a new generation. It restores record.validGeneration instead. That copy carries the bumped feeRateSatPerVbyte and any adopted funding, but its signed steps are still at the old rate. Nothing builds at the new rate afterwards. The only repeat is the check's unverified rebuild (SparkExitDriver.swift:613). It stops once the waiting leaf's steps read as ready, and broadcastReady then sends the old-rate steps. For a leaf skipped as fundsNotFound, operatorsUnavailable or recently stopped, even that once-a-block retry never runs. testAFeeBumpWithNothingToPayKeepsTheValidGenerationOverABuildThatDropsLeafOnesRefund (SparkExitTests.swift:1881) shows this. After applyFeeBump(rate: 10, ...), the second tick broadcasts generation 1's n2, which is the old-rate step, and nothing checks the rate afterwards. Because the record says 10 sat/vB, isNetworkAboveExitRate and SparkExitFeeBumpOption.options(exitRate:) treat the exit as already bumped. Only higher rates are offered, and only once a step is underpriced. A paid bump's extra funding sits unused at the funding address until the leftover sweep. rebuildStalled also clears isRedoRebuild, so the same guard can now keep a generation with a dead CPFP child. I did not trace that case to a visible failure. All of this comes from reading the code, not from a run. Possible fixes: mark the bump as still pending so a rebuild at the new rate runs once no waiting leaf blocks it, or keep the valid generation's old rate so Pay a higher fee is offered again. Nothing under Bitkit/Services/SparkExit changed since 31be1fe (git diff --stat is empty). validGeneration (SparkExitDriver.swift:1480-1488) still keeps the bumped rate over steps signed at the old rate. The reply defers the point; it does not show it is wrong. The PR body's Out of Scope also has no tracked issue for it.
  • Pay a higher fee still pays from Savings on a chain Spark does not read (Bitkit/Views/Settings/Advanced/SparkExitViews.swift:912-915). Only the start's funding screen got the new chain check. 15f76ba gates Savings funding there on SparkManager.shared.canFundFromChain (SparkExitViews.swift:197 and :247). The Pay a higher fee screen does not have that check. Its canPay (SparkExitViews.swift:912-915) and SparkExitManager.payHigherFee (SparkExitManager.swift:831) only check the spendable balance. On a dev build whose Spark uses its hosted regtest, Pay a higher fee sends the extra funding on Bitkit's staging chain. The exit never sees that payment: it sits in needsFunding until the missing-payment timeout and then asks for the funding again. Mainnet is not affected, because readsBitkitChain is always true there. Found by reading the code. canPay (SparkExitViews.swift:912-915) still checks only the spendable balance. canFundFromChain is still read only at :197 and :247, so Pay a higher fee can still pay from Savings on a chain Spark does not read. The reply defers the point without rebutting it, and the PR body's Out of Scope does not track it.

new since the last review

worth doing, does not block

  • The contact route still offers a Spark leg while an exit runs (Bitkit/Services/PaykitRouteService.swift:49-51). During an exit, contact payments are still offered a Spark leg that the new guard in withdraw (SparkManager.swift:984) then refuses after the swipe. PaykitRouteService.canRouteThroughSpark (PaykitRouteService.swift:49-51) checks only isReady && sendableSats > 0. Both stay true during an exit. reconnectForExit ends with refresh(), which sets isReady = true and balanceSats = info.balanceSats (SparkManager.swift:1548-1549). That balance still counts the exiting leaves, which SparkExitTests.swift:2837 relies on. This PR added isExiting checks to the Lightning paths (SendSheet.swift:338, WalletViewModel.swift:1452) but not to the route's checks. withdrawalRouteLeg (SparkManager.swift:920) and sparkWithdrawalBalanceSats (SendAmountView.swift:136-139) also skip the check. How it plays out during an exit, for a contact with only an on-chain endpoint and Savings at or below dust: - AppViewModel.swift:863-873 sets paykitRoute and selects Spending. - The router quotes a Spark leg through prepareSend, and Confirm shows it with its fee. - The swipe ends in SparkWithdrawalNotSent and the generic "payment failed" screen. - Its Try Again goes back to Confirm and fails the same way. - Because offersSparkLeg is true, SendSheet.swift:886 and :944 also skip the up-front check that rejects an on-chain request Savings can't pay. Nothing is sent, and the journal and proof are released, so this is only a dead end in the UI. Adding !SparkManager.shared.isExiting to canRouteThroughSpark would close it. Found by reading the code; I did not run it. CI's Run Tests for 9fab00f was still pending, and I did not build locally.

@ovitrif

ovitrif commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 16b7c66, with fixes for @coreyphillips's two points from his approval of 31be1fe:

  • e1dbb0b: a fee bump kept over a waiting leaf is now built at its rate.
  • 16b7c66: Pay a higher fee pays from Savings only on Spark's chain.

Each comes with tests. The full BitkitTests suite passes (3,812 tests, 0 failures, with CI's skips), and SwiftFormat lint is clean. The Android twins are in synonymdev/bitkit-android#1782 (24c5fedda).

Re-review is needed only because of this push: 9fab00f...16b7c66

@ovitrif

ovitrif commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Merging this PR into feat/breez-spark-lightning with a merge commit, on @coreyphillips's approval of 31be1fe, with CI green on 16b7c66. No changes are requested and no thread is open. synonymdev/bitkit-ios#1257, stacked on this branch, is retargeted to feat/breez-spark-lightning first.

What changed after that approval (31be1fe...16b7c66):

  • 793be4a merges in the base, which now has synonymdev/bitkit-ios#1191 (Route). There were no conflicts.
  • 9fab00f is the Route and Exit join, cherry-picked from the QA build's ba566a70f. A contact withdrawal is refused while an exit runs, and its send counts in the exit's spends, so an exit starting meanwhile waits for it or refuses it. It's needed because an exit spends every leaf. Without it, a Route withdrawal could spend leaves the exit is using, and git can't flag that, since the two PRs touch different lines.
  • e1dbb0b and 16b7c66 fix his two points from that review: a kept fee bump is now built at its rate, and Pay a higher fee pays from Savings only on Spark's chain.

Its Closes line (#1296) takes effect when the base reaches master with synonymdev/bitkit-ios#1128.

@ovitrif
ovitrif merged commit 07732c1 into feat/breez-spark-lightning Nov 12, 2026
11 checks passed
@ovitrif
ovitrif deleted the feat/spark-emergency-exit branch November 12, 2026 01:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants